Skip to content

docs: suggest a tag that isn't on the list - #40

Draft
alukach wants to merge 3 commits into
docs/product-tags-pickerfrom
docs/tag-suggestions
Draft

alukach wants to merge 3 commits into
docs/product-tags-pickerfrom
docs/tag-suggestions

Conversation

@alukach

@alukach alukach commented Oct 9, 2026 •

Copy link
Copy Markdown
Contributor

People can now suggest a tag from the product form's tag picker (source-cooperative/source.coop#648). This replaces the "contact hello@source.coop" line in Create a data product with how suggesting works: the tag goes on the product as pending, the team approves, merges or removes it, and each person can have five suggestions awaiting review. Stacked on #39; merge after it and alongside source.coop#648.

🤖 Generated with Claude Code

Users can now suggest a new tag from the tag picker (source-cooperative/source.coop feat/tag-suggestions), so describe that instead of emailing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 9, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
docs-source-coop Ready Ready Preview Oct 27, 2026 6:20pm UTC

Request Review

Suggested tags no longer appear publicly before an admin approves them (source-cooperative/source.coop#648).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Follows source-cooperative/source.coop#648, where a suggested tag waits in the form until the product is saved, and tags are spelled with hyphens.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
alukach added a commit to source-cooperative/source.coop that referenced this pull request Oct 28, 2026
…648)

People can suggest a tag the list doesn't have, straight from the
product form's tag picker, and admins review suggestions on a new admin
page.

![The tag picker: the product's tags first, then the
rest](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/tag-menu-product.png)

![Suggesting a tag from the
picker](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/tag-menu-suggest.png)

![A suggested tag on the product, pending
review](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/tagpicker-pending.png)

![The admin review queue, with likely
matches](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/admin-tags-v4-pending.png)

![Every approved tag for a merge, close matches
first](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/tag-menu-merge.png)

![Reviewed suggestions, with the products each was on, and
Undo](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/admin-tags-reviewed-products.png)

![A suggestion whose words are each a tag, offered as a
split](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/admin-tags-split.png)

![The split staged, waiting to be saved or
reset](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/admin-tags-split-staged.png)

## How it works

1. **Suggest.** The picker's popover lists the product's own tags first,
checked, then every other tag, filtered as you type; the arrow keys move
through it and Enter toggles a tag. When the filter text isn't an
existing tag, its last row offers *Suggest "sea-ice" as a new tag*. The
offer also appears when other tags only partly match, so someone after
`ocean-acidification` isn't stuck with `ocean`. Choosing it adds a chip
marked *(new)*. Nothing reaches the server yet.
2. **Saving the product suggests it.** The form submits new tags as
`suggest_tags`. `createProduct` and `updateProduct` check them with
`planSuggestions` (spelling, and the 5-suggestion cap), check the
product's tags with them, finish every other check, and only then write
the pending tags (`pending: true`, `suggested_by`, `suggested_at` in
`sc-{stage}-tags`) and the product. A save that fails suggests nothing,
and leaving without saving suggests nothing, so a suggestion can't be
left on no product. A refused suggestion shows under the Tags field.
3. **An admin reviews it** at `/admin/tags`, which lists each pending
tag, who suggested it, and the products carrying it. Each decision calls
the same operation as the matching API route:
- **Approve as a new tag** (a soft button on every card, so **Save
merge** is the one solid button) removes the pending flag, so the tag
joins the list everyone picks from.
- **Merge** takes two steps, and can go into one tag or several. The
card offers the approved tags the suggestion looks most like, and
**Other tag** (or **Merge into a tag** when nothing is close), an
outlined button with a chevron, opens every approved tag with how many
products carry it, the suggestion's close matches first, picked with the
mouse or the arrow keys and Enter. Each one chosen toggles in or out of
the staged merge. When every word of the suggestion is already an
approved tag (in the singular or the plural, via `splitTag`), one button
such as **field + boundaries** stages them all. The card then says what
saving will change, nothing happens until **Save merge**, and **Reset**
(on the right, in orange) goes back to the suggestion as it was. Saving
puts every chosen tag in the suggestion's place on each product carrying
it, once each, and records the decision.
- **Reject** takes it off every product carrying it and records the
decision.

Approve and Reject act at once. A refused decision, such as a tag
another admin already handled, is shown on its card.
4. **Every decision stays on record, and can be undone.** A suggestion
no longer disappears once decided. Its row keeps `outcome` (approved,
merged or rejected), `reviewed_by`, `reviewed_at`, the `merged_into`
targets and, for a merge or rejection, `changed`: each product it
rewrote and the tags each gained. Merged and rejected tags leave the
list, the picker and the save check (`isListed`, `isUsable`), but stay
in the table. The **Reviewed** section of `/admin/tags` lists them most
recent first, with the decision, who made it and when, the products the
tag was on (for a merge or rejection, those it rewrote; for an approval,
those carrying it now), each linked, and **Undo**. Undo puts the tag
back awaiting review; for a merge or rejection it first puts the tag
back on each product it was taken off and takes off the tags the merge
added, leaving any the product had before. A merge or rejection is
recorded before products change, and only while the tag is still
pending, so two admins can't both decide one. Suggesting a tag that was
merged or rejected puts it back in the queue. Below that, an **All
tags** table lists every approved tag, most used first, with its product
count and where it came from. Each tag links to the product list
filtered by it (`/products?tags=boundaries`). Tags seeded by
`clean-tags.ts` show as *Initial import*.

## API

Suggesting and reviewing go through operations in
`src/lib/operations/tags.ts`. The form actions and these routes both
adapt them, so the UI and the API can't disagree about a rule:

| Endpoint | Does | Who may |
|---|---|---|
| `GET /api/v1/tags` | Lists the approved tags, as one page. Admins also
see who suggested and approved each, and when | Anyone |
| `GET /api/v1/tags?pending=true` | Lists the tags awaiting review, with
who suggested each and when | Admins |
| `GET /api/v1/tags?reviewed=true` | Lists the suggested tags an admin
decided, most recent first, with the decision and what it changed |
Admins |
| `POST /api/v1/tags` `{ "tag_id": "sea-ice" }` | Suggests a tag on its
own: 201 with the new pending tag, or 200 with the tag if it already
exists | Any signed-in account |
| `POST`/`PATCH` on products with `suggest_tags` | Suggests tags and
adds them to the product in the same save; nothing is suggested if the
save fails | Whoever may create or edit the product |
| `PATCH /api/v1/tags/{tag_id}` `{ "pending": false }` | Approves a tag
| Admins |
| `PATCH /api/v1/tags/{tag_id}` `{ "pending": true }` | Undoes a
decision, reversing what a merge or rejection did to products | Admins |
| `DELETE /api/v1/tags/{tag_id}` | Rejects a pending tag, taking it off
every product; it stays on record | Admins |
| `DELETE
/api/v1/tags/{tag_id}?replace_with=field&replace_with=boundaries` |
Merges a pending tag into one or more approved tags on every product;
repeat `replace_with` for each | Admins |

How this fits #702:

- **Errors** are the existing RFC 9457 problems from #701: 400 `invalid`
for a malformed tag or a `replace_with` that isn't approved, 409
`conflict` past the five-suggestion cap or for deleting an approved tag,
and 401 or 403 from `deny`. No new problem types.
- **The reference** is the OpenAPI 3.1 document from #710; both route
files are imported in `src/app/api/openapi/route.ts`.
- **Safe retries (#708):** `POST /tags` answers 200 and the existing tag
rather than 409, so a retried suggestion is harmless without an
`Idempotency-Key`. API.md now states this convention for creates named
by their own content.
- **Conditional writes (#703):** the 409 for deleting a tag that is no
longer pending is the stale-form case `If-Match` will cover generally.
Once #703 lands, the review page could send the ETag it read instead.
- **`WWW-Authenticate` (#704)** will come to these routes' 401s through
`toResponse`, with nothing to change here.

## Guardrails

- Suggestions are spelled like every other tag: trimmed, lowercased,
with hyphens for spaces (`canonTag` in `src/lib/tags/canon.ts`, the same
file #719 adds), so typing `Sea Ice` suggests `sea-ice`. They must then
be at most 40 letters, digits or hyphens, starting and ending with a
letter or digit. The picker filters and offers suggestions in that
spelling too, so its button names the tag as it will be stored.
- Each account can have at most 5 pending suggestions.
- Suggesting a tag that already exists, approved or pending, just
returns it. Writes are conditional, so two people suggesting the same
tag at once can't overwrite each other.
- Only logged-in users can suggest. Only admins can review, which the
operations check themselves rather than relying on the `/admin` layout.
- `deleteTag` refuses to merge or reject a tag that is no longer
pending. Without that, a second admin's stale form could delete a tag
another admin just approved, and strip it from every product.
- A pending tag stays private in filters too: `listProducts` matches
`?tags=` again on the tags the caller can see, so filtering by a tag
awaiting review doesn't list the products carrying it.
- A merge, rejection or undo here rewrites each product with an
unconditioned write, one at a time, so an owner's save at the same
moment can be overwritten and a failure partway leaves some products
unchanged. #723, stacked on this PR, replaces those writes with
conditional DynamoDB transactions.

## Unsaved changes

The *You have unsaved changes* line by Save, the disabled Save, and the
ask before leaving a form with unsaved changes moved to #722, now merged
into `main`. The tag picker relies on it: a *(new)* tag is only
suggested once the product is saved, and #722 counts its hidden inputs
as changes.

## Account names

Every account `/admin/tags` names (who suggested a tag, who approved,
merged or rejected it) links to the account and shows its card on hover.
A new `AccountMention` (`src/components/core`) wraps an id in running
text in the existing `AccountInfoHoverCard`, and shows an id the page
couldn't look up, such as a deleted account's, as plain text. The page
looks up every account it names in one batch and hands the components
their `publicView`, so no private field reaches the browser. This
follows `CLAUDE.md`'s *Account names* rule, which is on `main` (#728);
this PR doesn't change `CLAUDE.md`.

![The reviewer's account card on
hover](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/admin-tags-account-card.png)

## Decisions worth a second look

- **Likely matches are a guess, so they are only offered.**
`similarTags` (`src/lib/tags/similar.ts`) ranks approved tags by
spelling: the same letters without separators, one containing the other,
shared words, or a couple of letters off. It knows no synonyms, so `sst`
finds nothing like `ocean`; **Other tag** covers that. Because a guess
can be wrong, a match only stages the merge, and the card says what
saving will change before anything does.

- **Pending tags appear only in the product's settings.**
`hidePendingTags` (`src/lib/clients/database/tags.ts`) takes them off
products shown on the product page, the products list, profiles and the
homepage. The API's product reads apply it too, except for callers who
may edit the product: they need to see every tag, or a tags list they
send back would drop pending ones unseen. It costs one scan of the tags
table per request, which the request cache shares. The `?tags=` search
filter still matches a pending tag, so filtering by one lists its
products, without showing the tag on them.
- **One table, not a separate suggestions table.** Approving a tag means
the app writes to the tag list anyway, so a separate table wouldn't keep
the list read-only. The Vercel role's grant on `sc-{stage}-tags` becomes
read-write.
- **A dot, not a count or a message.** While any tag is pending, admins
see a red dot on their avatar, on Admin in the account menu, on Tags
inside it and on Tags in the admin sidebar, the same way a pending
invitation shows. `anyPendingTags` answers it from the tags scan
`hidePendingTags` already makes, which the request cache shares, and
says no when the table can't be read so the header never fails over it.
A count, or a Slack message, could follow if suggestions still sit
unreviewed.

![The account menu with tags to
review](https://raw.githubusercontent.com/source-cooperative/source.coop/assets/product-tags/admin-tags-dot.png)
- **A rejection doesn't tell the suggester why.** The tag just
disappears from their product. A rejection note could follow if people
ask.
- **Merge and reject scan the products table** once per decision, and
the admin page scans it once per pending tag (marked with a `ponytail:`
comment). That's fine while the queue is short and there are a few
hundred products.
- **Running `clean-tags.ts` after this ships** would drop pending tags
from products, because they aren't in its corpus. It's meant to run
once, at rollout, before any suggestions exist.

## The tag menus

Both pickers, the product form's and the admin merge picker, share one
list, `TagMenu` (`src/components/features/products/TagMenu.tsx`): a
search field heading plain option rows in titled groups, with the active
row tracked through `aria-activedescendant`. Their rows used to be ghost
`Button`s, whose negative margins pushed them past the scrolling box,
clipping their first letters and scrolling the list sideways, and whose
full height made the hover highlight oversized. Options stay inside the
list's edges and highlight one 32 px row. The product's tags are held in
place while the menu is open, so a tag toggled off doesn't jump to the
other group under the cursor. Enter never submits the form behind the
popover.

## Stories

On this branch's Storybook deploy:

- TagMenu (new):
[MergeTargets](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-products-tagmenu--merge-targets),
[ProductTags](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-products-tagmenu--product-tags),
[NoMatch](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-products-tagmenu--no-match)
- TagPicker:
[Filtering](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-settings-products-tagpicker--filtering),
[Suggesting](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-settings-products-tagpicker--suggesting),
[NewTag](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-settings-products-tagpicker--new-tag)
(suggested, not yet saved),
[PendingTag](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-settings-products-tagpicker--pending-tag)
- AdminTagSuggestions:
[Pending](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--pending),
[MergeStaged](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--merge-staged),
[Refused](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--refused),
[Empty](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--empty),
[MergeIntoSeveral](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--merge-into-several),
[Split](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--split),
[SplitStaged](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--split-staged),
[OtherTag](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintagsuggestions--other-tag)
(the merge picker open)
- AdminReviewedTags:
[Reviewed](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-adminreviewedtags--reviewed),
[UndoRefused](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-adminreviewedtags--undo-refused),
[Empty](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-adminreviewedtags--empty),
[AccountCard](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-adminreviewedtags--account-card)
(a name hovered)
- AdminTagList:
[AllTags](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintaglist--all-tags),
[Filtered](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintaglist--filtered),
[Empty](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-admin-admintaglist--empty)
- AccountDropdown (new):
[Default](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-app-shell-accountdropdown--default),
[Invited](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-app-shell-accountdropdown--invited),
[TagsToReview](https://source-coop-ui-git-feat-tag-suggestions-radiantearth.vercel.app/?path=/story/features-app-shell-accountdropdown--tags-to-review)

`.storybook/preview.tsx` mocks the new `src/lib/actions/tags.ts` module,
and `src/lib/actions/credentials.ts`, which the AccountDropdown story
reaches through the uploader providers.

## Docs

- `CLAUDE.md` is unchanged here: its *Account names* rule landed on
`main` in #728, and this PR is rebased on it.
- `docs/using-source/create-a-data-product.md` told people to email
hello@source.coop for a missing tag. Updated in
[docs.source.coop#40](source-cooperative/docs.source.coop#40),
stacked on #39.
- `public/llms.txt` gets a line for `GET /api/v1/tags`, next to product
search, since `?tags=` filters on those tags. It needs no credentials,
so the URL resolves in production once this ships. Tags have no bearing
on the data-proxy ADRs (authorization, credentials, federation), and the
proxy calls none of these endpoints, so its contract test is unaffected.
- `API.md` gains the rule that a create named by its own content answers
200 with the existing resource.

## Testing

- The full jest suite passes locally (1253 tests), as does `npm run
type-check`. `npm run lint` reports nothing in the changed files.
`src/components/core/AccountMention.test.tsx` covers the link to a
looked-up account and the plain-text fallback. Undo is covered in
`src/lib/operations/tags.test.ts`: a merge restoring the tag and taking
off only what it added (a product that had a target before keeps it, a
deleted product is skipped), an approval only reopening, nothing to undo
on a seeded tag, and re-suggesting a rejected tag. The same file covers
recording each decision before products change, a race losing with 409
and no product changed, and the reviewed list.
`AdminReviewedTags.test.tsx` covers the decision wording, the products
each tag was on, and Undo.
- New `src/components/features/admin/AdminTagSuggestions.test.tsx`
covers choosing a match only staging the merge, Reset going back, Save
merge sending the merge and its target, and a refusal being shown. New
`src/lib/tags/similar.test.ts` covers which approved tags a suggestion
looks like.
- New `src/lib/operations/tags.test.ts` covers the rules once: listing
(pending only for admins), suggesting (normalizing, existing tags,
sign-in, malformed tags, the cap), approving (admins only, recording the
approver and keeping the suggester, harmless repeats, not found), admins
seeing who suggested and approved each tag and deleting (merge without
duplicates, a `replace_with` that isn't approved or is empty, reject,
refusing an approved tag). `src/lib/actions/tags.test.ts` now checks
only the mapping, and `src/app/api/v1/tags/route.test.ts` checks that a
suggestion answers 201 or 200.
- `src/lib/operations/products.test.ts` adds a case where a product
saves with a pending tag, and one where only callers who may edit a
product see its pending tags. New
`src/lib/clients/database/tags.test.ts` covers `hidePendingTags` taking
pending tags off, keeping them where asked, and leaving other products
as they are. `tags.test.ts` covers `reviewTag` refusing to merge or
reject a tag that isn't pending.
- `tags.test.ts` covers `anyPendingTags` saying yes when a tag is
pending, no when none is, and no when the table can't be read. The new
AccountDropdown stories were checked rendering in a local Storybook.
- New `TagMenu.test.tsx` covers empty groups left out, the arrow keys
moving across groups to the action and wrapping, and Enter and a click
choosing. `src/lib/operations/products.test.ts` covers filtering by a
pending tag returning nothing to a caller who can't see it.
`TagPicker.test.tsx` covers no suggesting a tag the product already
carries, the product's tags listed first and staying put when unchecked,
Enter toggling without submitting the form, suggesting a tag (a *(new)*
chip submitted as `suggest_tags`, with the save reminder, until
removed), an existing tag not being offered for suggestion, and no
suggesting without `suggestName`. `src/lib/operations/products.test.ts`
covers a save suggesting only the tags the list lacks, a failing save
suggesting nothing, an update adding suggestions to the product's own
tags, and the cap refusing a save. `src/lib/actions/products.test.ts`
covers the form sending `suggest_tags` and a refused suggestion showing
under Tags.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
Preview — b512fea2 Deployed Oct 27, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant